TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import Link from "next/link";5import { useParams } from "next/navigation";6import { ArrowLeft, Ban, CheckCircle2, Scale, UserCheck, XCircle } from "lucide-react";7import { Button, Tabs } from "@/components/ui";8import { api } from "@/lib/api";9import { toast } from "@/lib/store";10import { timeAgo } from "@/lib/utils";11import { useAdminQuery } from "@/components/admin/use-query";12import type { LedgerRow, RoundRow, SecurityEventRow, SessionRow, UserDetailResponse } from "@/components/admin/types";13import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, KV, Pill, UserStatusPill, SeverityPill, ConfirmDialog, DenseInput, DenseTextarea, FieldLabel, InlineError, JsonToggle, Mono, StatTile, StatGrid, type Column } from "@/components/admin/primitives";14import { dateTime, describeError, int, multiplier, sc, signedSC } from "@/components/admin/format";15import { cn } from "@/lib/utils";1617type Tab = "ledger" | "rounds" | "sessions" | "events";1819const ledgerCols: Column<LedgerRow>[] = [20 { key: "at", header: "When", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },21 { key: "type", header: "Type", render: (r) => <Pill tone={r.type === "ADMIN_ADJUSTMENT" ? "warn" : r.type === "BET" ? "muted" : r.type === "WIN" ? "accent" : "neutral"}>{r.type}</Pill> },22 { key: "amount", header: "Amount", align: "right", render: (r) => <span className={cn(r.amount > 0 ? "text-success" : r.amount < 0 ? "text-fg-2" : "text-fg-3")}>{signedSC(r.amount)}</span> },23 { key: "after", header: "Balance after", align: "right", render: (r) => sc(r.balanceAfter) },24 { key: "ref", header: "Reference", mono: true, render: (r) => <Mono>{r.reference ?? "—"}</Mono> },25 { key: "meta", header: "Meta", render: (r) => <JsonToggle value={r.meta} /> },26];2728const roundCols: Column<RoundRow>[] = [29 { key: "at", header: "When", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },30 { key: "game", header: "Game", render: (r) => <Link href={`/admin/games/${r.gameSlug}`} className="hover:text-accent-2">{r.gameSlug}</Link> },31 { key: "bet", header: "Bet", align: "right", render: (r) => sc(r.bet) },32 { key: "win", header: "Win", align: "right", render: (r) => <span className={r.win > 0 ? "text-credit" : "text-fg-3"}>{sc(r.win)}</span> },33 { key: "mult", header: "×", align: "right", render: (r) => multiplier(r.multiplier) },34 { key: "features", header: "Features", render: (r) => (r.features.length ? <span className="text-fg-2">{r.features.join(", ")}</span> : <span className="text-fg-4">—</span>) },35 { key: "flags", header: "", render: (r) => <span className="flex gap-1">{r.freeSpins ? <Pill tone="info">FS</Pill> : null}{r.bonus ? <Pill tone="accent">Bonus</Pill> : null}{r.jackpotTier ? <Pill tone="warn">{r.jackpotTier}</Pill> : null}</span> },36 { key: "after", header: "Balance after", align: "right", render: (r) => sc(r.balanceAfter) },37 { key: "ms", header: "ms", align: "right", render: (r) => r.durationMs ?? "—" },38 { key: "id", header: "Round", mono: true, render: (r) => <Mono>{r.roundId}</Mono> },39];4041const sessionCols: Column<SessionRow>[] = [42 { key: "created", header: "Started", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },43 { key: "seen", header: "Last seen", render: (r) => timeAgo(r.lastSeenAt) },44 { key: "expires", header: "Expires", render: (r) => <span className="text-fg-3">{dateTime(r.expiresAt)}</span> },45 { key: "ip", header: "IP", mono: true, render: (r) => <Mono>{r.ip ?? "—"}</Mono> },46 { key: "ua", header: "User agent", render: (r) => <span className="block max-w-[420px] truncate text-fg-3" title={r.userAgent ?? ""}>{r.userAgent ?? "—"}</span> },47];4849const eventCols: Column<SecurityEventRow>[] = [50 { key: "at", header: "When", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },51 { key: "type", header: "Type", mono: true, render: (r) => <Mono className="text-fg">{r.type}</Mono> },52 { key: "sev", header: "Severity", render: (r) => <SeverityPill severity={r.severity} /> },53 { key: "ip", header: "IP", mono: true, render: (r) => <Mono>{r.ip ?? "—"}</Mono> },54 { key: "meta", header: "Meta", render: (r) => <JsonToggle value={r.meta} /> },55];5657export default function AdminUserDetailPage() {58 const { id } = useParams<{ id: string }>();59 const q = useAdminQuery<UserDetailResponse>(id ? `/api/admin/users/${id}` : null);60 const d = q.data;61 const [tab, setTab] = React.useState<Tab>("ledger");62 const [adjustOpen, setAdjustOpen] = React.useState(false);63 const [statusOpen, setStatusOpen] = React.useState(false);6465 return (66 <>67 <PageHeader68 eyebrow="User"69 title={d ? d.user.username : "User detail"}70 description={d ? <span className="font-mono text-[12px]">{d.user.id}</span> : undefined}71 actions={72 <>73 <Button variant="ghost" size="sm" href="/admin/users">74 <ArrowLeft className="h-3.5 w-3.5" /> All users75 </Button>76 <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />77 </>78 }79 />8081 {q.error && !d ? (82 <ErrorState error={q.error} onRetry={() => void q.refresh()} title={q.error.status === 404 ? "User not found" : undefined} />83 ) : !d ? (84 <div className="grid gap-4 lg:grid-cols-3">85 <Panel>86 <TableSkeleton rows={4} cols={2} />87 </Panel>88 <Panel>89 <TableSkeleton rows={4} cols={2} />90 </Panel>91 <Panel>92 <TableSkeleton rows={4} cols={2} />93 </Panel>94 </div>95 ) : (96 <div className={cn("space-y-4", q.stale && "opacity-70")}>97 <div className="grid gap-4 lg:grid-cols-3">98 {/* Profile */}99 <Panel title="Profile" actions={<UserStatusPill status={d.user.status} />}>100 <KV101 items={[102 { label: "Username", value: d.user.username },103 { label: "Level", value: `${d.user.level} · ${int(d.user.xp)} XP` },104 { label: "Created", value: dateTime(d.user.createdAt) },105 { label: "Last login", value: d.user.lastLoginAt ? `${timeAgo(d.user.lastLoginAt)} · ${dateTime(d.user.lastLoginAt)}` : "never" },106 { label: "Age confirmed", value: d.user.ageConfirmedAt ? dateTime(d.user.ageConfirmedAt) : "—" },107 { label: "Last rescue", value: d.user.lastRescueAt ? dateTime(d.user.lastRescueAt) : "—" },108 { label: "Total spins", value: int(d.user.totalSpins) },109 { label: "Games played", value: int(d.user.gamesPlayed) },110 { label: "Biggest win", value: sc(d.user.biggestWin) },111 { label: "Biggest multiplier", value: multiplier(d.user.biggestMultiplier) },112 ]}113 />114 <div className="mt-4 flex flex-wrap gap-2 border-t border-line pt-4">115 {d.user.status === "suspended" ? (116 <Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>117 <UserCheck className="h-3.5 w-3.5" /> Reactivate account118 </Button>119 ) : (120 <Button variant="danger" size="sm" onClick={() => setStatusOpen(true)} disabled={d.user.status === "deleted"}>121 <Ban className="h-3.5 w-3.5" /> Suspend account122 </Button>123 )}124 </div>125 </Panel>126127 {/* Wallet */}128 <Panel129 title="Wallet"130 actions={131 d.invariant.ok ? (132 <Pill tone="success">133 <CheckCircle2 className="h-3 w-3" /> ledger = balance134 </Pill>135 ) : (136 <Pill tone="danger">137 <XCircle className="h-3 w-3" /> ledger ≠ balance138 </Pill>139 )140 }141 >142 <div className="text-[11px] font-medium uppercase tracking-wider text-fg-4">Balance</div>143 <div className="text-3xl font-semibold tracking-tight text-credit">{sc(d.wallet?.balance ?? 0)}</div>144 <KV145 className="mt-4"146 items={[147 { label: "Lifetime granted", value: sc(d.wallet?.lifetimeGranted ?? 0) },148 { label: "Lifetime wagered", value: sc(d.wallet?.lifetimeWagered ?? 0) },149 { label: "Lifetime won", value: sc(d.wallet?.lifetimeWon ?? 0) },150 { label: "Updated", value: d.wallet ? timeAgo(d.wallet.updatedAt) : "—" },151 ]}152 />153 <div className={cn("mt-4 rounded-sm border px-3 py-2 text-[12px]", d.invariant.ok ? "border-success/30 bg-success/5 text-fg-2" : "border-danger/40 bg-danger/10 text-danger")}>154 <div className="flex items-center gap-1.5 font-semibold uppercase tracking-wider">155 <Scale className="h-3 w-3" /> Ledger invariant156 </div>157 <div className="mt-1 flex justify-between tabular">158 <span>Σ ledger amounts</span>159 <span>{sc(d.invariant.ledgerTotal)}</span>160 </div>161 <div className="flex justify-between tabular">162 <span>Wallet balance</span>163 <span>{sc(d.invariant.balance)}</span>164 </div>165 {!d.invariant.ok ? <div className="mt-1 font-medium">Mismatch of {signedSC(d.invariant.balance - d.invariant.ledgerTotal)} — investigate before adjusting.</div> : null}166 </div>167 <div className="mt-4 border-t border-line pt-4">168 <Button variant="outline" size="sm" onClick={() => setAdjustOpen(true)}>169 Adjust wallet170 </Button>171 </div>172 </Panel>173174 {/* Activity snapshot */}175 <div className="grid content-start gap-3">176 <StatGrid cols={3} className="grid-cols-2">177 <StatTile label="Ledger rows" value={int(d.ledger.length)} sub="latest 50" compact />178 <StatTile label="Rounds" value={int(d.rounds.length)} sub="latest 30" compact />179 <StatTile label="Sessions" value={int(d.sessions.length)} sub="active + expired" compact />180 </StatGrid>181 <StatGrid cols={3} className="grid-cols-2">182 <StatTile label="Security events" value={int(d.events.length)} tone={d.events.some((e) => e.severity === "high") ? "danger" : "neutral"} sub="latest 30" compact />183 <StatTile label="Round RTP" value={roundRtp(d.rounds)} sub="latest rounds" compact />184 <StatTile label="Bonus rounds" value={int(d.rounds.filter((r) => r.bonus || r.freeSpins).length)} compact />185 </StatGrid>186 </div>187 </div>188189 <Panel190 padded={false}191 title={192 <Tabs<Tab>193 value={tab}194 onChange={setTab}195 items={[196 { value: "ledger", label: `Ledger (${d.ledger.length})` },197 { value: "rounds", label: `Rounds (${d.rounds.length})` },198 { value: "sessions", label: `Sessions (${d.sessions.length})` },199 { value: "events", label: `Security (${d.events.length})` },200 ]}201 />202 }203 >204 {tab === "ledger" ? <DataTable columns={ledgerCols} rows={d.ledger} rowKey={(r) => r.id} dense empty="No ledger entries." /> : null}205 {tab === "rounds" ? <DataTable columns={roundCols} rows={d.rounds} rowKey={(r) => r.id} dense empty="No rounds played." /> : null}206 {tab === "sessions" ? <DataTable columns={sessionCols} rows={d.sessions} rowKey={(r) => r.id} dense empty="No sessions." /> : null}207 {tab === "events" ? <DataTable columns={eventCols} rows={d.events} rowKey={(r) => r.id} dense empty="No security events for this user." /> : null}208 </Panel>209210 <AdjustDialog open={adjustOpen} onClose={() => setAdjustOpen(false)} userId={d.user.id} username={d.user.username} balance={d.wallet?.balance ?? 0} onDone={() => void q.refresh()} />211 <StatusDialog open={statusOpen} onClose={() => setStatusOpen(false)} userId={d.user.id} username={d.user.username} suspended={d.user.status === "suspended"} onDone={() => void q.refresh()} />212 </div>213 )}214 </>215 );216}217218function roundRtp(rounds: RoundRow[]): string {219 const bet = rounds.reduce((a, r) => a + r.bet, 0);220 const win = rounds.reduce((a, r) => a + r.win, 0);221 return bet ? `${((win / bet) * 100).toFixed(1)}%` : "—";222}223224function AdjustDialog({ open, onClose, userId, username, balance, onDone }: { open: boolean; onClose: () => void; userId: string; username: string; balance: number; onDone: () => void }) {225 const [amount, setAmount] = React.useState("");226 const [note, setNote] = React.useState("");227 const [busy, setBusy] = React.useState(false);228 const [error, setError] = React.useState<string | null>(null);229 const [confirm, setConfirm] = React.useState(false);230231 const n = Number(amount);232 const amountOk = /^-?\d+$/.test(amount.trim()) && n !== 0 && Math.abs(n) <= 1_000_000_000;233 const noteOk = note.trim().length >= 3;234 const after = balance + (amountOk ? n : 0);235236 function reset() {237 setAmount("");238 setNote("");239 setError(null);240 setConfirm(false);241 }242243 async function submit() {244 if (!amountOk || !noteOk) return;245 setBusy(true);246 setError(null);247 try {248 const r = await api<{ balance: number }>(`/api/admin/users/${userId}/adjust`, { json: { amount: n, note: note.trim() } });249 toast({ title: "Wallet adjusted", description: `${username} · ${signedSC(n)} → ${sc(r.balance)}`, tone: "success" });250 reset();251 onClose();252 onDone();253 } catch (e) {254 setError(describeError(e));255 setConfirm(false);256 } finally {257 setBusy(false);258 }259 }260261 return (262 <ConfirmDialog263 open={open}264 onClose={() => {265 if (busy) return;266 reset();267 onClose();268 }}269 onConfirm={() => (confirm ? void submit() : setConfirm(true))}270 title={confirm ? "Confirm wallet adjustment" : `Adjust wallet · ${username}`}271 description={confirm ? undefined : "Signed amount in Spinza Credits (SC). Positive grants, negative removes. Logged as a high-severity security event."}272 confirmLabel={confirm ? `Apply ${signedSC(amountOk ? n : 0)}` : "Review"}273 danger={amountOk && n < 0}274 loading={busy}275 disabled={!amountOk || !noteOk}276 >277 {confirm ? (278 <div className="rounded-sm border border-line bg-bg-1 p-3 text-[13px]">279 <div className="flex justify-between">280 <span className="text-fg-3">Player</span>281 <span className="font-medium">{username}</span>282 </div>283 <div className="mt-1 flex justify-between">284 <span className="text-fg-3">Adjustment</span>285 <span className={cn("font-semibold tabular", n > 0 ? "text-success" : "text-danger")}>{signedSC(n)}</span>286 </div>287 <div className="mt-1 flex justify-between">288 <span className="text-fg-3">Balance</span>289 <span className="tabular">290 {sc(balance)} → <span className="text-credit">{sc(after)}</span>291 </span>292 </div>293 <div className="mt-2 border-t border-line pt-2 text-fg-2">“{note.trim()}”</div>294 {after < 0 ? <InlineError>Resulting balance would be negative.</InlineError> : null}295 </div>296 ) : (297 <div className="space-y-3">298 <div>299 <FieldLabel hint={amountOk ? `→ ${sc(after)}` : "integer, non-zero"}>Amount (SC)</FieldLabel>300 <DenseInput inputMode="numeric" placeholder="e.g. 5000 or -2500" value={amount} onChange={(e) => setAmount(e.target.value.replace(/[^\d-]/g, ""))} className="font-mono" />301 </div>302 <div>303 <FieldLabel hint={`${note.trim().length}/200 · min 3`}>Note</FieldLabel>304 <DenseTextarea rows={2} maxLength={200} placeholder="Why is this adjustment being made?" value={note} onChange={(e) => setNote(e.target.value)} />305 </div>306 <div className="flex gap-1.5">307 {[1000, 5000, 10000, -1000].map((v) => (308 <button key={v} type="button" onClick={() => setAmount(String(v))} className="rounded-xs border border-line px-2 py-1 text-[11px] text-fg-3 hover:text-fg">309 {signedSC(v)}310 </button>311 ))}312 </div>313 </div>314 )}315 {error ? (316 <div className="mt-3">317 <InlineError>{error}</InlineError>318 </div>319 ) : null}320 </ConfirmDialog>321 );322}323324function StatusDialog({ open, onClose, userId, username, suspended, onDone }: { open: boolean; onClose: () => void; userId: string; username: string; suspended: boolean; onDone: () => void }) {325 const [busy, setBusy] = React.useState(false);326 const [error, setError] = React.useState<string | null>(null);327 const next = suspended ? "active" : "suspended";328329 async function submit() {330 setBusy(true);331 setError(null);332 try {333 await api(`/api/admin/users/${userId}/status`, { json: { status: next } });334 toast({ title: next === "suspended" ? "Account suspended" : "Account reactivated", description: username, tone: next === "suspended" ? "danger" : "success" });335 onClose();336 onDone();337 } catch (e) {338 setError(describeError(e));339 } finally {340 setBusy(false);341 }342 }343344 return (345 <ConfirmDialog open={open} onClose={onClose} onConfirm={() => void submit()} title={suspended ? `Reactivate ${username}?` : `Suspend ${username}?`} description={suspended ? "The player will be able to sign in and play again." : "All active sessions are revoked immediately and the player cannot sign in until reactivated. Credits and progress are kept."} confirmLabel={suspended ? "Reactivate" : "Suspend"} danger={!suspended} loading={busy}>346 {error ? <InlineError>{error}</InlineError> : null}347 </ConfirmDialog>348 );349}350